package org.codefilarete.stalactite.query.builder;

import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;

import org.codefilarete.stalactite.query.model.ConditionalOperator;
import org.codefilarete.stalactite.query.model.Fromable;
import org.codefilarete.stalactite.query.model.Placeholder;
import org.codefilarete.stalactite.query.model.Selectable;
import org.codefilarete.stalactite.query.model.ValuedVariable;
import org.codefilarete.stalactite.sql.ddl.structure.Column;
import org.codefilarete.stalactite.sql.statement.ExpandableSQL;
import org.codefilarete.stalactite.sql.statement.ExpandableSQL.ExpandableParameter;
import org.codefilarete.stalactite.sql.statement.ExpandableStatement;
import org.codefilarete.stalactite.sql.statement.PreparedSQL;
import org.codefilarete.stalactite.sql.statement.SQLParameterParser.ParsedSQL;
import org.codefilarete.stalactite.sql.statement.SQLStatement.BindingException;
import org.codefilarete.stalactite.sql.statement.binder.ColumnBinderRegistry;
import org.codefilarete.stalactite.sql.statement.binder.ParameterBinder;
import org.codefilarete.stalactite.sql.statement.binder.PreparedStatementWriter;
import org.codefilarete.tool.Reflections;
import org.codefilarete.tool.trace.MutableInt;

/**
 * {@link SQLAppender} that can handle not-yet-set values from {@link ConditionalOperator}s as well as
 * already-set ones.
 * - Set values are those made of {@link ValuedVariable} and a numeric (incremental) placeholder is affected to them
 * - Not-yet-set values are placeholder ones made of {@link Placeholder} and the variable name is affected to them
 * Final result can be converted to a {@link PreparedSQL} with given values, see {@link #toPreparedSQL(Map)} 
 * 
 * @see #toPreparedSQL(Map)
 * @author Guillaume Mary
 */
public class ExpandableSQLAppender implements SQLAppender {
	
	/**
	 * Used to store SQL snippets
	 */
	private final List<Object /* Placeholder, StringSQLAppender, or SubSQLAppender */> sqlSnippets = new ArrayList<>();
	private final DMLNameProvider dmlNameProvider;
	
	/**
	 * Current "String" in which expressions are appended. The instance is put into the {@link ParsedSQL} instance as a sql snippet.
	 * The reference changes as soon as a placeholder or a variable is added to {@link ExpandableSQLAppender}.
	 */
	private StringSQLAppender currentSQLSnippet;
	private final ColumnBinderRegistry parameterBinderRegistry;
	/**
	 * Collected {@link ParameterBinder} per variable name
	 */
	private final Map<String, ParameterBinder> parameterBinders;
	/**
	 * Collected values per variable name
	 */
	private final Map<String, Object> values;
	/**
	 * Counter for unnamed variables : each time a raw value is appended to the {@link ExpandableSQLAppender} instance, this counter is incremented
	 * and a variable name is created for it, simply made of the Integer as a String
	 */
	private final MutableInt paramCounter;
	
	public ExpandableSQLAppender(ColumnBinderRegistry parameterBinderRegistry, DMLNameProvider dmlNameProvider) {
		this.dmlNameProvider = dmlNameProvider;
		this.parameterBinderRegistry = parameterBinderRegistry;
		this.parameterBinders = new HashMap<>();
		this.values = new HashMap<>();
		this.paramCounter = new MutableInt();
		initCurrentSqlSnippet();
	}
	
	/**
	 * Private constructor for {@link #newSubPart(DMLNameProvider)}
	 * @param dmlNameProvider
	 * @param parameterBinderRegistry
	 * @param parameterBinders
	 * @param values
	 * @param paramCounter
	 */
	private ExpandableSQLAppender(
			DMLNameProvider dmlNameProvider,
			ColumnBinderRegistry parameterBinderRegistry,
			Map<String, ParameterBinder> parameterBinders,
			Map<String, Object> values,
			MutableInt paramCounter) {
		this.dmlNameProvider = dmlNameProvider;
		this.parameterBinderRegistry = parameterBinderRegistry;
		this.parameterBinders = parameterBinders;
		this.values = values;
		this.paramCounter = paramCounter;
		initCurrentSqlSnippet();
	}
	
	public List<Object> getSqlSnippets() {
		return sqlSnippets;
	}
	
	public Map<String, Object> getValues() {
		return values;
	}
	
	@Override
	public SQLAppender cat(String s, String... ss) {
		currentSQLSnippet.cat(s, ss);
		return this;
	}
	
	@Override
	public <V> SQLAppender catValue(@Nullable Selectable<V> column, Object value) {
		ParameterBinder<?> parameterBinder;
		if (column == null) {
			parameterBinder = getParameterBinderFromRegistry(value);
		} else if (column instanceof Column) {
			parameterBinder = parameterBinderRegistry.getBinder((Column) column);
		} else {
			parameterBinder = parameterBinderRegistry.getBinder(column.getJavaType());
		}
		return catValue(value, parameterBinder);
	}
	
	@Override
	public SQLAppender catValue(Object variable) {
		if (variable instanceof ValuedVariable) {
			Object value = ((ValuedVariable) variable).getValue();
			if (value instanceof Selectable) {
				// Columns are simply appended (no binder needed nor index increment)
				currentSQLSnippet.catColumn((Selectable) value);
			} else {
				addPlaceholder(value, getParameterBinderFromRegistry(value));
			}
		} else if (variable instanceof Placeholder) {
			addPlaceholder((Placeholder<?, ?>) variable);
		} else {
			addPlaceholder(variable, getParameterBinderFromRegistry(variable));
		}
		return this;
	}
	
	private ParameterBinder<?> getParameterBinderFromRegistry(Object value) {
		ParameterBinder<?> parameterBinder;
		if (value instanceof ValuedVariable) {
			parameterBinder = getParameterBinderFromRegistry(((ValuedVariable) value).getValue());
		} else if (value instanceof Placeholder) {
			parameterBinder = parameterBinderRegistry.getBinder(((Placeholder) value).getValueType());
		} else {
			Class<?> binderType = value.getClass().isArray() ? value.getClass().getComponentType() : value.getClass();
			parameterBinder = parameterBinderRegistry.getBinder(binderType);
		}
		return parameterBinder;
	}
	
	private SQLAppender catValue(Object value, ParameterBinder<?> binderSupplier) {
		if (value instanceof ValuedVariable) {
			Object innerValue = ((ValuedVariable) value).getValue();
			if (innerValue instanceof Selectable) {
				// Columns are simply appended (no binder needed nor index increment)
				currentSQLSnippet.catColumn((Selectable) innerValue);
			} else {
				addPlaceholder(innerValue, binderSupplier);
			}
		} else if (value instanceof Placeholder) {
			addPlaceholder((Placeholder<?, ?>) value);
		} else {
			addPlaceholder(value, binderSupplier);
		}
		return this;
	}
	
	private void addPlaceholder(Placeholder<?, ?> variable) {
		sqlSnippets.add(variable);
		parameterBinders.put(variable.getName(), parameterBinderRegistry.getBinder(variable.getValueType()));
		initCurrentSqlSnippet();
	}
	
	private void addPlaceholder(Object value, ParameterBinder<?> binderSupplier) {
		String paramName = String.valueOf(paramCounter.increment());
		sqlSnippets.add(new Placeholder<>(paramName, binderSupplier.getColumnType()));
		parameterBinders.put(paramName, binderSupplier);
		values.put(paramName, value);
		initCurrentSqlSnippet();
	}
	
	protected void initCurrentSqlSnippet() {
		currentSQLSnippet = new StringSQLAppender(dmlNameProvider);
		sqlSnippets.add(currentSQLSnippet);
	}
	
	@Override
	public SQLAppender catColumn(Selectable<?> column) {
		// Columns are simply appended (no binder needed nor index increment)
		currentSQLSnippet.catColumn(column);
		return this;
	}
	
	@Override
	public SQLAppender catTable(Fromable table) {
		currentSQLSnippet.catTable(table);
		return this;
	}
	
	@Override
	public SQLAppender removeLastChars(int length) {
		currentSQLSnippet.removeLastChars(length);
		return this;
	}
	
	/**
	 * Implementation based on {@link ParsedSQL#toString()}. Not really useful for a real SQL statement, prefer {@link #toPreparedSQL(Map)} 
	 * 
	 * @return a representation of internal SQL snippets
	 */
	@Override
	public String getSQL() {
		StringBuilder result = new StringBuilder();
		visitSQLSnippets(placeholder -> 
				result.append(":").append(placeholder.getName()),
				sqlAppender -> result.append(sqlAppender.getSQL()),
				subSQLAppender -> result.append(subSQLAppender.getSQL()));
		return result.toString();
	}
	
	private void visitSQLSnippets(Consumer<Placeholder<?, ?>> placeholderConsumer,
								  Consumer<StringSQLAppender> sqlAppenderConsumer,
								  Consumer<DefaultSubSQLAppender> subSQLAppenderConsumer) {
		this.sqlSnippets.forEach(sqlSnippet -> {
			if (sqlSnippet instanceof Placeholder) {
				placeholderConsumer.accept((Placeholder<?, ?>) sqlSnippet);
			} else if (sqlSnippet instanceof StringSQLAppender) {
				sqlAppenderConsumer.accept((StringSQLAppender) sqlSnippet);
			} else if (sqlSnippet instanceof DefaultSubSQLAppender) {
				subSQLAppenderConsumer.accept((DefaultSubSQLAppender) sqlSnippet);
			} else {
				throw new IllegalStateException("Unsupported SQL snippet: "
						+ (sqlSnippet == null ? "null" : Reflections.toString(sqlSnippet.getClass())));
			}
		});
	}
	
	@Override
	public SubSQLAppender newSubPart(DMLNameProvider dmlNameProvider) {
		SubSQLAppender result = new DefaultSubSQLAppender(
				new ExpandableSQLAppender(
						dmlNameProvider,
						// we give all our attributes to make the subpart fill the global identifier counter and values, this avoids a complex
						// computation at close() time to propagate the value to the parent
						this.parameterBinderRegistry,
						this.parameterBinders,
						this.values,
						this.paramCounter)) {
			@Override
			public SQLAppender close() {
				// we ask for a new SQL snippet, it will reinitialize this.currentSQLSnippet and therefore prepare for another iteration as specified by newSubPart(..) 
				ExpandableSQLAppender.this.initCurrentSqlSnippet();
				return ExpandableSQLAppender.this;
			}
		};
		this.sqlSnippets.add(result);
		return result;
	}
	
	/**
	 * Creates a {@link PreparedSQL} from given values.
	 * Given values are merged with those of current instance, hence only named placeholders values are required.
	 * However, values present in original SQL can be overwritten by giving a value to their numeric placeholder. 
	 * 
	 * @param values values per named placeholder
	 * @return a new {@link PreparedSQL} for given values set
	 */
	public PreparedSQL toPreparedSQL(Map<String, Object> values) {
		Map<String, Object> mergedValues = new HashMap<>(getValues());
		mergedValues.putAll(values);
		// we unwrap Variables from values because PreparedSQL doesn't support it  
		unwrapVariables(mergedValues);
		
		// we ask ExpandableSQL to build the SQL made of "?" for each of our values
		Map<String, Integer> valuesSizes = ExpandableSQL.sizes(mergedValues);
		ParsedSQL parsedSQL = new ParsedSQL();
		
		class ParsedSQLHelper {
			
			void add(ExpandableSQLAppender appender) {
				appender.visitSQLSnippets(this::add,
						this::add,
						subSQLAppender -> add((ExpandableSQLAppender) subSQLAppender.getDelegate()));
			}
			
			void add(Placeholder variable) {
				parsedSQL.addParam(variable.getName());
			}
			
			void add(StringSQLAppender sqlSnippet) {
				parsedSQL.addSqlSnippet(sqlSnippet.getSQL());
			}
		}
		
		new ParsedSQLHelper().add(this);
		
		ExpandableSQL expandableSQL = new ExpandableSQL(parsedSQL, valuesSizes);
		String placeholderSql = expandableSQL.getPreparedSQL();
		
		// Computing parameter binders for each "?" index
		Map<Integer, PreparedStatementWriter<?>> placeholderBinders = new HashMap<>();
		Map<Integer, Object> placeholderValues = new HashMap<>();
		expandableSQL.getExpandableParameters().forEach((paramName, expandableParameter) -> {
			Object value = mergedValues.get(paramName);
			if (value == null && !mergedValues.containsKey(paramName)) {
				// value not given at all (null or not)
				throw new BindingException("No value given for parameter named '" + paramName + "' : " + placeholderSql);
			}
			int[] markIndexes = expandableParameter.getMarkIndexes();
			for (int markIndex : markIndexes) {
				placeholderBinders.put(markIndex, parameterBinders.get(paramName));
			}
			ExpandableStatement.adaptIterablePlaceholders(value, markIndexes, placeholderValues::put);
		});
		
		PreparedSQL result = new PreparedSQL(placeholderSql, placeholderBinders);
		result.setValues(placeholderValues);
		return result;
	}
	
	/**
	 * Replaces {@link ValuedVariable} values by their concrete values.
	 * Note that this method replaces values of the given {@link Map} (for simplicity of calling algorithm)
	 * 
	 * @param mergedValues {@link Map} in which {@link ValuedVariable} must be replaced by their own internal values
	 */
	private void unwrapVariables(Map<String, Object> mergedValues) {
		mergedValues.entrySet().forEach(entry -> {
			if (entry.getValue() instanceof ValuedVariable) {
				entry.setValue(((ValuedVariable<?>) entry.getValue()).getValue());
			}
		});
	}
}
